fix(approval): preserve replacement routes during cleanup#4786
fix(approval): preserve replacement routes during cleanup#4786samrusani wants to merge 3 commits into
Conversation
📝 WalkthroughWalkthrough
ChangesParked approval cleanup
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant ApprovalFuture
participant ApprovalGate
participant ApprovalStore
participant RoutingMappings
ApprovalFuture->>ApprovalGate: park approval
ApprovalGate->>ApprovalStore: insert pending approval
ApprovalFuture->>ApprovalGate: external abort
ApprovalGate->>RoutingMappings: clear owned routes
ApprovalGate->>ApprovalStore: persist Deny
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dbcc54bc9a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| self.gate.clear_thread(&self.thread_id); | ||
| self.gate.clear_meeting(&self.in_call_ctx); |
There was a problem hiding this comment.
Avoid clearing newer approval routes
When an old parked approval is cancelled because a newer turn starts, the old task is only signalled and can be dropped after the new turn has already parked its own approval on the same thread/meeting. These key-only clears then remove whatever request is currently stored for that thread/meeting, so a fresh approval's yes/no reply no longer routes through pending_for_thread/pending_for_meeting and the new request can hang until TTL. Please remove the mapping only if it still points at this guard's request_id.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/openhuman/approval/gate.rs (2)
1400-1479: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSolid regression coverage for the external-abort cleanup path.
Both tests correctly validate waiter eviction, routing-map cleanup, and durable Deny persistence for chat- and meeting-routed parks. One gap worth considering: there's no test exercising the race this design is built to survive —
decide()committing anApprove*decision concurrently with the abort, verifying the guard'sstore::decide(Deny)becomes a no-op (per theWHERE decided_at IS NULLconditional) rather than clobbering the already-persisted decision. That's the core correctness guarantee cited in the PR objectives ("conditional updates preserve concurrently committed decisions") and isn't directly covered here.Want me to draft a test that calls
gate.decide(&request_id, ApprovalDecision::ApproveOnce)immediately beforehandle.abort()and asserts the persisted decision staysApproveOnce?🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/openhuman/approval/gate.rs` around lines 1400 - 1479, Add regression coverage for the abort/decision race in the existing external-abort waiter tests: commit an approval via gate.decide using the captured request_id immediately before aborting the spawned waiter, then assert cleanup still occurs and store::get_decision remains ApprovalDecision::ApproveOnce. Ensure the test exercises the conditional Deny cleanup path so it cannot overwrite an already-persisted approval.
206-271: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winBlocking SQLite write inside
Drop::dropcan stall the runtime thread performing the cancellation.
Drop::drop(lines 237-270) callsstore::decide(&self.gate.config, &self.request_id, ApprovalDecision::Deny)synchronously (line 254).Dropcannot yield, so whichever thread runs this destructor — plausibly a Tokio worker thread reclaiming the task duringabort()or an outer-deadline drop — is blocked for the duration of the SQLite write/lock acquisition. Tokio's own docs call this out explicitly: "if you call a non-async method from async code, that non-async method is still inside the asynchronous context, so you should also avoid blocking operations there. This includes destructors of objects destroyed in async code."This mirrors the existing synchronous
store::decidecalls at lines 842/862, but doing it inDropis strictly worse: there's no way to bound it with a timeout or defer it, and it fires precisely during the failure scenario this PR targets (turn-deadline cancellation), which is exactly when many tasks might be torn down in a burst.Consider offloading via
tokio::task::spawn_blocking(fire-and-forget, logging errors from the spawned task) so the drop path never blocks its calling thread — or explicitly document that local SQLite writes are treated as acceptable "fast enough" blocking work here.♻️ Possible direction (fire-and-forget via spawn_blocking)
match store::decide(&self.gate.config, &self.request_id, ApprovalDecision::Deny) { - Ok(Some(_)) => tracing::debug!(...), - Ok(None) => tracing::debug!(...), - Err(err) => tracing::error!(...), - } + } + let config = self.gate.config.clone(); + let request_id = self.request_id.clone(); + tokio::task::spawn_blocking(move || { + match store::decide(&config, &request_id, ApprovalDecision::Deny) { + Ok(Some(_)) => tracing::debug!(request_id = %request_id, "..."), + Ok(None) => tracing::debug!(request_id = %request_id, "..."), + Err(err) => tracing::error!(request_id = %request_id, error = %err, "..."), + } + });Note
Configmust be cheaply cloneable (or wrapped inArc) for this to work without extra cost — worth confirming before adopting.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/openhuman/approval/gate.rs` around lines 206 - 271, Remove the synchronous store::decide call from ParkedApprovalCleanupGuard::drop so destructor execution never performs SQLite I/O. Preserve the existing in-memory cleanup and denial behavior by cloning or otherwise safely capturing the required config and request_id, then dispatching the denial persistence through tokio::task::spawn_blocking with equivalent success and error logging inside the spawned task; handle any task-spawn failure without blocking Drop.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/openhuman/approval/gate.rs`:
- Around line 1400-1479: Add regression coverage for the abort/decision race in
the existing external-abort waiter tests: commit an approval via gate.decide
using the captured request_id immediately before aborting the spawned waiter,
then assert cleanup still occurs and store::get_decision remains
ApprovalDecision::ApproveOnce. Ensure the test exercises the conditional Deny
cleanup path so it cannot overwrite an already-persisted approval.
- Around line 206-271: Remove the synchronous store::decide call from
ParkedApprovalCleanupGuard::drop so destructor execution never performs SQLite
I/O. Preserve the existing in-memory cleanup and denial behavior by cloning or
otherwise safely capturing the required config and request_id, then dispatching
the denial persistence through tokio::task::spawn_blocking with equivalent
success and error logging inside the spawned task; handle any task-spawn failure
without blocking Drop.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 16d5574d-ba95-469e-bc01-db894687d36f
📒 Files selected for processing (1)
src/openhuman/approval/gate.rs
|
Addressed the replacement-route race in
Validation:
@codex review |
|
Codex Review: Didn't find any major issues. 🎉 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Reconciled this branch with current The PR is now a one-file incremental follow-up:
Validation: 36 approval-gate tests passed; fmt, clippy (existing warnings only), diff check, and the full repository pre-push hook passed. The PR is mergeable again and its title/body now describe only the remaining delta. @codex review |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Context
PR #4819 merged the core RAII waiter guard and closed #4774 after this PR was opened. This PR has been reconciled with current
mainand now contains only the remaining cleanup safeguards and regression coverage; it does not replace or duplicate the merged guard.Remaining problem
Changes
Validation
env GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml --lib openhuman::approval::gate::tests::(36 passed)cargo fmt --manifest-path Cargo.toml --all --checkenv GGML_NATIVE=OFF cargo clippy -p openhuman --lib(existing warning baseline only)git diff --checkScope
One implementation/test file:
src/openhuman/approval/gate.rs.Related: #4774, #4819
Summary by CodeRabbit